Skip to content

Conversation

@TarunAdobe
Copy link
Contributor

@TarunAdobe TarunAdobe commented Nov 27, 2025

A Storybook addon that simulates screen reader behavior, providing instant audio and visual feedback as you navigate components.

What it does

Voice Reader: Speaks announcements using Web Speech API
Text Reader: Displays announcements in the addon panel
Tab through your component → hear/see what a screen reader would announce

How it works

Tracks focus via focusin events
Computes accessible names using dom-accessibility-api (W3C AccName spec)
Maps elements to ARIA roles via aria-query
Announces: role, name, states (checked, expanded, disabled, etc.)

Implementation

This addon is a shared local package at 2nd-gen/packages/swc/.storybook/addons/screen-reader-addon/:

  • Written in TypeScript (.ts/.tsx)
  • No build step: Storybook's internal esbuild compiles source directly
  • Uses Spectrum Web Components: sp-switch, sp-theme, sp-textfield, sp-help-text, sp-field-label
  • Shared across both Storybooks: Used by 1st-gen and 2nd-gen
  • Theme-aware: Automatically matches Storybook UI theme (light/dark) via luminance detection
  • Dependencies: Bundled packages (aria-query, dom-accessibility-api, query-selector-shadow-dom) are installed in the parent package

Limitations

This is a development aid, not a replacement for real screen reader testing.
Good for: Quick feedback on accessible names, roles, and states
Not for: Final accessibility sign-off (use VoiceOver/NVDA for that)

Further Reading

See RFC for discussion on ownership, maintenance, and where this should live long-term.

Testing

First gen:

Go here

  • Click on Screen Reader addon
  • Toggle Voice Reader / Text Reader
  • Use TAB and ARROW keys to navigate the focus on the component and listen to & read the screen reader annoucements

Note: Check the same storybook screen reader addon here in second-gen though it's impossible to verify if it works the same cz second-gen has no focusable component :(

@changeset-bot
Copy link

changeset-bot bot commented Nov 27, 2025

⚠️ No Changeset found

Latest commit: 752ddf3

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions
Copy link
Contributor

github-actions bot commented Nov 27, 2025

📚 Branch Preview Links

🔍 First Generation Visual Regression Test Results

When a visual regression test fails (or has previously failed while working on this branch), its results can be found in the following URLs:

Deployed to Azure Blob Storage: pr-5911

If the changes are expected, update the current_golden_images_cache hash in the circleci config to accept the new images. Instructions are included in that file.
If the changes are unexpected, you can investigate the cause of the differences and update the code accordingly.

@TarunAdobe TarunAdobe force-pushed the ttomar/screen-reader-plugin branch from 384201c to 736dcbb Compare December 8, 2025 07:32
@TarunAdobe TarunAdobe force-pushed the ttomar/screen-reader-plugin branch from 9c470bc to 45aea25 Compare January 9, 2026 06:54
@TarunAdobe TarunAdobe marked this pull request as ready for review January 9, 2026 06:54
@TarunAdobe TarunAdobe requested a review from a team as a code owner January 9, 2026 06:54
"playwright": "1.53.1",
"postcss": "8.5.6",
"postcss-preset-env": "10.4.0",
"query-selector-shadow-dom": "^1.0.0",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please lock all dependency versions 😄

* Stop the screen reader
*/
stop(): void {
if (!this.isRunning && !this.storyDocument) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible you mean OR, not AND?

Suggested change
if (!this.isRunning && !this.storyDocument) {
if (!this.isRunning || !this.storyDocument) {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and nit: if you do change this... we need a storyDocument cleanup at the end of this stop method.
The storyDocument reference is never set to null, which could prevent garbage collection 🤔

): string {
const announcements: Record<string, RoleAnnouncementFn> = {
link: () => {
const visited = element.matches(':visited') ? 'visited ' : '';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eheh, this is ingenious, but I think it does not work... Browsers are super aggressive with their privacy so this will always return false.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did not know!!!! :(

/**
* Generate announcement for an element based on its role and state
*/
announceElement(element: Element | null): void {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we considered adding an aria-hidden check to skip elements hidden from the accessibility tree? If I understand this correctly we are now announcing them?

role: string,
name: string
): string {
const announcements: Record<string, RoleAnnouncementFn> = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about the disabled state? Could we append something like , disabled if it has aria-disabled (I guess if it has disabled attribute it is probably not focusable so we don't need to worry about that part? maybe confirm)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're right... if disabled then no focusable so we no worry but for aria-disabled i should add a skip

* Generate announcement for an element based on its role and state
*/
announceElement(element: Element | null): void {
if (!element || element === this.lastAnnouncedElement) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm... State changes on the focused element aren't announced. For example, if a checkbox is toggled, it's the same element, so we're skipping it here.
We could add something along the lines of an announceStateChange(element, attribute) method that announces only the new state.
We'd need to update onMutation to call it for things like aria-checked, aria-expanded, aria-pressed (can't remember any other) changes on the focused element. BTW, I see that currently only aria-selected triggers a re-announcement (line 741).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed! We now have:

  • Mutation observer for aria-checked, aria-pressed, aria-expanded, aria-selected - announces state changes directly
  • Change event handler for elements with checked property (like sp-checkbox) - catches native form element patterns

Both paths announce just the state ("checked", "pressed", "expanded") without re-announcing the full element.

@TarunAdobe TarunAdobe force-pushed the ttomar/screen-reader-plugin branch from 42d3efc to 752ddf3 Compare January 9, 2026 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants